import { extname } from 'node:path'
import Stream from 'node:stream'
import assert from 'node:assert'
import http from 'node:http'
import net from 'node:net'
import { Buffer } from "node:buffer"
import statuses from '../http/statuses.js'
import onFinished from '../http/onFinished.js'
import destroyStream from '../http/destroyStream.js'
import { getContentType, typeIs } from '../http/mime.js'
import vary from '../http/vary.js'
import contentDisposition from '../http/contentDisposition.js'
import encodeUrl from '../http/encodeUrl.js'
import escapeHtml from '../http/escapeHtml.js'
import { IHttpApplication, IHttpContext, IHttpResponse } from "../interface/index.ts"

export class HttpResponse implements IHttpResponse {

  _body?: any
  _explicitNullBody?: boolean
  _explicitStatus?: boolean

  [key: string]: any

  constructor(
    public app: IHttpApplication,
    public ctx: IHttpContext,
    public req: http.IncomingMessage,
    public res: http.ServerResponse) {
  }

  get socket(): net.Socket {
    return this.res.socket
  }

  get headers(): http.OutgoingHttpHeaders {
    return this.res.getHeaders()
  }

  get status(): number {
    return this.res.statusCode
  }

  /**
   * Set status.
   */
  set status(code: number) {
    if (this.headerSent) return

    assert(Number.isInteger(code), 'status code must be a number')
    assert(code >= 100 && code <= 999, `invalid status code: ${code}`)
    this._explicitStatus = true
    this.res.statusCode = code
    if (this.req.httpVersionMajor < 2) this.res.statusMessage = statuses[code]
    if (this.body && statuses.empty[code]) this.body = null
  }

  get message(): string {
    return this.res.statusMessage || statuses[this.status]
  }

  set message(msg: string) {
    this.res.statusMessage = msg
  }

  get body(): string | Buffer | object | Stream {
    return this._body
  }

  /**
   * Set response body.
   */
  set body(val: string | Buffer | object | Stream) {
    const original = this._body
    this._body = val

    // no content
    if (val == null) {
      if (!statuses.empty[this.status]) {
        if (this.type === 'application/json') {
          this._body = 'null'
          return
        }
        this.status = 204
      }
      if (val === null) this._explicitNullBody = true
      this.remove('Content-Type')
      this.remove('Content-Length')
      this.remove('Transfer-Encoding')
      return
    }

    // set the status
    if (!this._explicitStatus) this.status = 200

    // set the content-type only if not yet set
    const setType = !this.has('Content-Type')

    // string
    if (typeof val === 'string') {
      if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text'
      this.length = Buffer.byteLength(val)
      return
    }

    // buffer
    if (Buffer.isBuffer(val)) {
      if (setType) this.type = 'bin'
      this.length = val.length
      return
    }

    // stream
    if (val instanceof Stream) {
      onFinished(this.res, destroyStream.bind(null, val))
      if (original !== val) {
        val.once('error', err => this.ctx.onerror(err))
        // overwriting
        if (original != null) this.remove('Content-Length')
      }

      if (setType) this.type = 'bin'
      return
    }

    // json
    this.remove('Content-Length')
    this.type = 'json'
  }

  /**
   * Set Content-Length field to `n`.
   */
  set length(n: number) {
    if (!this.has('Transfer-Encoding')) {
      this.set('Content-Length', String(n))
    }
  }

  /**
   * Return parsed response Content-Length when present.
   */
  get length(): number {
    if (this.has('Content-Length')) {
      return parseInt(this.get('Content-Length'), 10) || 0
    }

    const { body } = this
    if (!body || body instanceof Stream) return undefined
    if (typeof body === 'string') return Buffer.byteLength(body)
    if (Buffer.isBuffer(body)) return body.length
    return Buffer.byteLength(JSON.stringify(body))
  }

  /**
   * Check if a header has been written to the socket.
   */
  get headerSent(): boolean {
    return this.res.headersSent
  }

  /**
   * Vary on `field`.
   */
  vary(field: string): void {
    if (this.headerSent) return

    vary(this.res, field)
  }

  /**
   * Perform a 302 redirect to `url`.
   *
   * The string "back" is special-cased
   * to provide Referrer support, when Referrer
   * is not present `alt` or "/" is used.
   *
   * Examples:
   *
   *    this.redirect('back');
   *    this.redirect('back', '/index.html');
   *    this.redirect('/login');
   *    this.redirect('http://google.com');
   */
  redirect(url: string, alt: string): void {
    // location
    if (url === 'back') url = this.ctx.get('Referrer') || alt || '/'
    this.set('Location', encodeUrl(url))

    // status
    if (!statuses.redirect[this.status]) this.status = 302

    // html
    if (this.ctx.accepts('html')) {
      url = escapeHtml(url)
      this.type = 'text/html; charset=utf-8'
      this.body = `Redirecting to <a href="${url}">${url}</a>.`
      return
    }

    // text
    this.type = 'text/plain; charset=utf-8'
    this.body = `Redirecting to ${url}.`
  }

  /**
   * Set Content-Disposition header to "attachment" with optional `filename`.
   */
  attachment(filename: string, options: any) {
    if (filename) this.type = extname(filename)
    this.set('Content-Disposition', contentDisposition(filename, options))
  }

  /**
   * Set Content-Type response header with `type` through `mime.lookup()`
   * when it does not contain a charset.
   *
   * Examples:
   *
   *     this.type = '.html';
   *     this.type = 'html';
   *     this.type = 'json';
   *     this.type = 'application/json';
   *     this.type = 'png';
   */
  set type(type: string) {
    type = getContentType(type)
    if (type) {
      this.set('Content-Type', type)
    } else {
      this.remove('Content-Type')
    }
  }

  /**
   * Set the Last-Modified date using a string or a Date.
   *
   *     this.response.lastModified = new Date();
   *     this.response.lastModified = '2013-09-13';
   */
  set lastModified(val: string | Date) {
    if (typeof val === 'string') val = new Date(val)
    this.set('Last-Modified', val.toUTCString())
  }

  /**
   * Get the Last-Modified date in Date form, if it exists.
   */
  get lastModified(): Date {
    const date = this.get('last-modified')
    if (date) return new Date(date)
  }

  /**
   * Set the ETag of a response.
   * This will normalize the quotes if necessary.
   *
   *     this.response.etag = 'md5hashsum';
   *     this.response.etag = '"md5hashsum"';
   *     this.response.etag = 'W/"123456789"';
   */
  set etag(val: string) {
    if (!/^(W\/)?"/.test(val)) val = `"${val}"`
    this.set('ETag', val)
  }

  /**
   * Get the ETag of a response.
   */
  get etag(): string {
    return this.get('ETag')
  }

  /**
   * Return the response mime type void of
   * parameters such as "charset".
   */
  get type(): string {
    const type = this.get('Content-Type')
    if (!type) return ''
    return type.split(';', 1)[0]
  }

  /**
   * Check whether the response is one of the listed types.
   * Pretty much the same as `this.request.is()`.
   *
   * @param {String|String[]} [type]
   * @param {String[]} [types]
   * @return {String|false}
   */
  is(type: string | string[], ...types: string[]): string | false {
    return typeIs(this.type, type, ...types)
  }

  /**
   * Return response header.
   *
   * Examples:
   *
   *     this.get('Content-Type');
   *     // => "text/plain"
   *
   *     this.get('content-type');
   *     // => "text/plain"
   *
   */
  get(field: string): any {
    return this.res.getHeader(field)
  }

  /**
   * Returns true if the header identified by name is currently set in the outgoing headers.
   * The header name matching is case-insensitive.
   *
   * Examples:
   *
   *     this.has('Content-Type');
   *     // => true
   *
   *     this.get('content-type');
   *     // => true
   */
  has(field: string): boolean {
    return this.res.hasHeader(field)
  }

  /**
   * Set header `field` to `val` or pass
   * an object of header fields.
   *
   * Examples:
   *
   *    this.set('Foo', ['bar', 'baz']);
   *    this.set('Accept', 'application/json');
   *    this.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' });
   */
  set(field: string, val: string | Array<string>): void
  set(field: object | Array<any>): void
  set(field: any, val: any = undefined): void {
    if (this.headerSent) return
    if (val != null) {
      if (Array.isArray(val)) val = val.map(v => typeof v === 'string' ? v : String(v))
      else if (typeof val !== 'string') val = String(val)
      this.res.setHeader(field as string, val)
    } else {
      for (const key in (field as object | Array<string>)) {
        this.set(key, field[key])
      }
    }
  }

  /**
   * Append additional header `field` with value `val`.
   *
   * Examples:
   *
   * ```
   * this.append('Link', ['<http://localhost/>', '<http://localhost:3000/>']);
   * this.append('Set-Cookie', 'foo=bar; Path=/; HttpOnly');
   * this.append('Warning', '199 Miscellaneous warning');
   * ```
   */
  append(field: string, val: string | string[]): void {
    const prev = this.get(field)

    if (prev) {
      val = Array.isArray(prev) ? prev.concat(val) : [prev].concat(val)
    }

    this.set(field, val)
  }

  /**
   * Remove header `field`.
   */
  remove(field: string): void {
    if (this.headerSent) return

    this.res.removeHeader(field)
  }

  /**
   * Checks if the request is writable.
   * Tests for the existence of the socket
   * as node sometimes does not set it.
   */
  get writable(): boolean {
    // can't write any more after response finished
    if (this.res.writableEnded) return false

    const socket = this.res.socket
    // There are already pending outgoing res, but still writable
    // https://github.com/nodejs/node/blob/v4.4.7/lib/_http_server.js#L486
    if (!socket) return true
    return socket.writable
  }

  /**
   * Inspect implementation.
   */
  inspect(): any {
    if (!this.res) return
    const o = this.toJSON()
    o.body = this.body
    return o
  }

  /**
   * Return JSON representation.
   */
  toJSON(): { status: number, message: string, headers: http.OutgoingHttpHeaders, body?: any } {
    return {
      status: this.status,
      message: this.message,
      headers: this.headers
    }
  }

  /**
   * Flush any set headers and begin the body
   */
  flushHeaders(): void {
    this.res.flushHeaders()
  }

}
